這兒幾個位元組,那裡幾個位元組,我們說的是真正的內存
今天的帖子來自於最近的 Go 語言的一次小測試,觀察下面的測試基礎片段 [1] :
func BenchmarkSortStrings(b *testing.B) {
s := []string{"heart", "lungs", "brain", "kidneys", "pancreas"}
b.ReportAllocs()
for i := 0; i < b.N; i++ {
sort.Strings(s)
}
}
sort.Strings
是 sort.StringSlice(s)
的便捷包裝器,sort.Strings
在原地對輸入進行排序,因此不會分配內存(或至少 43% 回答此問題的 Twitter 用戶是這麼認為的)。然而,至少在 Go 的最近版本中,基準測試的每次迭代都會導致一次堆分配。為什麼會是這種情況?
正如所有 Go 程序員應該知道的那樣,介面是以 雙詞結構 實現的。每個介面值包含一個欄位,其中保存介面內容的類型,以及指向介面內容的指針。 [2]
在 Go 語言偽代碼中,一個介面可能是這樣的:
type interface struct {
// the ordinal number for the type of the value
// assigned to the interface
type uintptr
// (usually) a pointer to the value assigned to
// the interface
data uintptr
}
interface.data
可以容納一個機器字(在大多數情況下為 8 個位元組),但一個 []string
卻需要 24 個位元組:一個字用於指向切片的底層數組;一個字用於存儲切片的長度;另一個字用於存儲底層數組的剩餘容量。那麼,Go 是如何將 24 個位元組裝入個 8 個位元組的呢?通過編程中最古老的技巧,即間接引用。一個 []string
,即 s
,需要 24 個位元組;但 *[]string
—— 即指向字元串切片的指針,只需要 8 個位元組。
逃逸到堆
為了讓示例更加明確,以下是重新編寫的基準測試,不使用 sort.Strings
輔助函數:
func BenchmarkSortStrings(b *testing.B) {
s := []string{"heart", "lungs", "brain", "kidneys", "pancreas"}
b.ReportAllocs()
for i := 0; i < b.N; i++ {
var ss sort.StringSlice = s
var si sort.Interface = ss // allocation
sort.Sort(si)
}
}
為了讓介面正常運行,編譯器將賦值重寫為 var si sort.Interface = &ss
,即 ss
的地址分配給介面值。 [3] 我們現在有這麼一種情況:出現一個持有指向 ss
的指針的介面值。它指向哪裡?還有 ss
存儲在哪個內存位置?
似乎 ss
被移動到了堆上,這也同時導致了基準測試報告中的分配:
Total: 296.01MB 296.01MB (flat, cum) 99.66%
8 . . func BenchmarkSortStrings(b *testing.B) {
9 . . s := []string{"heart", "lungs", "brain", "kidneys", "pancreas"}
10 . . b.ReportAllocs()
11 . . for i := 0; i < b.N; i++ {
12 . . var ss sort.StringSlice = s
13 296.01MB 296.01MB var si sort.Interface = ss // allocation
14 . . sort.Sort(si)
15 . . }
16 . . }
發生這種分配是因為編譯器當前無法確認 ss
比 si
生存期更長。Go 編譯器開發人員對此的普遍態度是,覺得 這個問題改進的餘地,不過我們另找時間再議。事實上,ss
就是被分配到了堆上。因此,問題變成了:每次迭代會分配多少個位元組?為什麼不去詢問 testing
包呢?
% go test -bench=. sort_test.go
goos: darwin
goarch: amd64
cpu: Intel(R) Core(TM) i7-5650U CPU @ 2.20GHz
BenchmarkSortStrings-4 12591951 91.36 ns/op 24 B/op 1 allocs/op
PASS
ok command-line-arguments 1.260s
可以看到,在 amd 64 平台的 Go 1.16 beta1 版本上,每次操作會分配 24 位元組。 [4] 然而,在同一平台先前的 Go 版本中,每次操作則消耗了 32 位元組。
% go1.15 test -bench=. sort_test.go
goos: darwin
goarch: amd64
BenchmarkSortStrings-4 11453016 96.4 ns/op 32 B/op 1 allocs/op
PASS
ok command-line-arguments 1.225s
這引出了本文的主題,即 Go 1.16 版本中即將推出的一項便利改進。不過在討論這個內容之前,我需要聊聊 「 尺寸類別 」。
尺寸類別
在解釋什麼是 「 尺寸類別 」 之前,我們先考慮個問題,理論上的 Go 語言在運行時是如何在其堆上分配 24 位元組的。有一個簡單的方法:追蹤目前為止已分配到的所有內存的動向——利用指向堆上最後分配的位元組的指針。分配 24 位元組,堆指針就會增加 24,然後將前一個值返回給調用函數。只要寫入的請求 24 位元組的代碼不超出該標記的範圍,這種機制就沒有額外開銷。不過,現實情況下,內存分配器不僅要分配內存,有時還得釋放內存。
最終,Go 語言程序在運行時將釋放這些 24 位元組,但從運行的視角來看,它只知道它給調用者的開始地址。它不知道從該地址起始之後又分配了多少位元組。為了允許釋放內存,我們假設的 Go 語言程序運行時分配器必須記錄堆上每個分配的長度值。那麼這些長度值的分配存儲在何處?當然是在堆上。
在我們的設想中,當程序運行需要分配內存的時候,它可以請求稍微多一點,並把它用來存儲請求的數量。而對於我們的切片示例而言,當我們請求 24 位元組時,實際上會消耗 24 位元組加上存儲數字 24
的一些開銷。這些開銷有多大?事實上,實際上的最小開銷量是一個字。 [5]
用來記錄 24 位元組分配的開銷將是 8 位元組。25% 不是很大,但也不算糟糕,隨著分配的大小增加,開銷將變得微不足道。然而,如果我們只想在堆上存儲一個位元組,會發生什麼?開銷將是請求數據量的 8 倍!是否有一種更高效的方式在堆上分配少量內存?
與其在每個分配旁邊存儲長度,不如將相同大小的內容存儲在一起,這個主意如何?如果所有的 24 位元組的內容都存儲在一起,那麼運行時會自動獲取它們的大小。運行時所需要的是一個單一的位,指示 24 位元組區域是否在使用中。在 Go 語言中,這些區域被稱為 Size Classes,因為相同大小的所有內容都會存儲在一起(類似學校班級,所有學生都按同一年級分班,而不是 C++ 中的類)。當運行時需要分配少量內存時,它會使用能夠容納該分配的最小的尺寸類別。
無限制的尺寸類別
現在我們知道尺寸類別是如何工作的了,那麼問題又來了,它們存儲在哪裡?和我們想的一樣,尺寸類別的內存來自堆。為了最小化開銷,運行時會從堆上分配較大的內存塊(通常是系統頁面大小的倍數),然後將該空間用於單個大小的分配。不過,這裡存在一個問題————
將大塊區域用於存儲同一大小的事物的模式很好用 [6] ,如果分配大小的數量是固定的,最好是少數幾個。那麼在通用語言中,程序可以要求運行時以任何大小分配內存 [7] 。
例如,想像一下向運行時請求 9 位元組。9 位元組是一個不常見的大小,因此可能需要一個新的尺寸類別來存儲 9 位元組大小的物品。因為 9 位元組大小的物品不常見,所以分配的其餘部分(通常為 4KB 或更多)可能會被浪費。由於尺寸類別的集合是固定的,如果沒有精確匹配的 size class 可用,分配將併入到下一個尺寸類別。在我們的示例中,9 位元組可能會在 12 位元組的尺寸類別中分配。未使用的 3 位元組的開銷要比幾乎未使用的整個尺寸類別分配好。
總結一下
這是謎題的最後一塊拼圖。Go 1.15 版本沒有 24 位元組的尺寸類別,因此 ss
的堆分配是在 32 位元組的尺寸類別中分配的。由於 Martin Möhrmann 的工作,Go 1.16 版本有一個 24 位元組的尺寸類別,非常適合分配給介面的切片值。
相關文章
- 我在 Devfest 2017年西伯利亞大會談 Go 語言
- 如果對齊的內存寫操作是原子性的,為什麼我們還需要 sync/atomic 包呢?
- 為你的樹莓派創建一個真實的串列控制台
- 為什麼 Go 語言線程的棧是無限制的?
(題圖:MJ/01d5fe46-778f-48fe-9481-162f4d0289dc)
- 這不是正確的對排序函數進行基準測試的方式,因為在第一次迭代之後,輸入已經排序。但這又是另外一個話題了。 ↩︎
- 此語句的準確性取決於所使用的 Go 版本。例如,Go 1.15 版本添加了直接將一些 整數存儲在介面值 中的功能,從而節省了分配和間接性。然而,對於大多數值來說,如果它不是指針類型,它的地址將被取出並存儲在介面值中。 ↩︎
- 編譯器在介面值的類型欄位中跟蹤了這種手法,因此它記住了分配給
si
的類型是sort.StringSlice
而不是*sort.StringSlice
。 ↩︎ - 在 32 位平台上,這個數字減半,但我們不再關注它。 ↩︎
- 如果你準備限制分配為 4G 或者可能是 64KB,你可以使用較少內存來存儲分配的尺寸,但實際上使用小於一個字來存儲長度標頭的節省會受到填充的影響。 ↩︎
- 將相同大小的物品存儲在一起也是一種有效的對抗碎片化的策略。 ↩︎
- 這並不是一個不切實際的設想,字元串有各種形狀和大小,生成以前未見過的大小的字元串可能就像附加空格一樣簡單。 ↩︎
作者:Dave Cheney 選題:lujun9972 譯者:Drwhooooo 校對:wxy
本文轉載來自 Linux 中國: https://github.com/Linux-CN/archive